Skip to content

fix(tools): reuse supervisor session for browser_cdp target_id (salvage #32950) - #71743

Open
chrisyoung2005 wants to merge 2 commits into
NousResearch:mainfrom
chrisyoung2005:fix/browser-cdp-supervisor-target-reuse
Open

fix(tools): reuse supervisor session for browser_cdp target_id (salvage #32950)#71743
chrisyoung2005 wants to merge 2 commits into
NousResearch:mainfrom
chrisyoung2005:fix/browser-cdp-supervisor-target-reuse

Conversation

@chrisyoung2005

@chrisyoung2005 chrisyoung2005 commented Jul 26, 2026

Copy link
Copy Markdown

Fixes #32685. Salvage of #32950 (credit @LeonSGP43 — that branch has been conflicting since May with the review feedback unaddressed; all three review asks are applied here). Triage marked this best fix — fixes for the #32685 family.

Summary

  • Symptom: browser_cdp opens a fresh CDP WebSocket per call. On Browserless-style backends that spawn a private browser per connection, a target id returned by one call is invalid by the next — the documented Target.getTargetstarget_id workflow always fails, and even a discovery call enumerates a different browser than the one a follow-up call executes in.
  • Change: when the task has a live CDPSupervisor, browser_cdp rides its persistent WebSocket: target_id calls reuse the supervisor session already attached to that target (top-level page, OOPIF frame, or auto-attached child — CDPSupervisor.resolve_target_session), and browser-level calls (no target_id, e.g. Target.getTargets) go out on the same connection (_browser_cdp_target_via_supervisor; payloads carry "connection": "supervisor"). SupervisorSnapshot gains a public page_target_id (surfaced in browser_snapshot output) so agents never read supervisor internals. No supervisor, or a target the supervisor doesn't track → the legacy stateless attach, unchanged.
  • Behavior: a discovery → target_id chain observes ONE browser on per-connection backends; plain Chrome (targets shared across connections) behaves as before. The route runs after the shared _browser_cdp_private_guard (can't become the sibling bypass fixed for frame_id in 47764f1) and the supervisor payload goes through the same _redact_cdp_output boundary as the stateless one. website/docs/user-guide/features/browser.md no longer claims every non-frame_id call is stateless.

Evidence

scripts/run_tests.sh tests/tools/test_browser_cdp_tool.py                                   # 30 passed
HERMES_E2E_BROWSER=1 scripts/run_tests.sh tests/tools/test_browser_supervisor.py -m integration   # 9 passed, 1 skipped (real Chromium)

Unit: private-page guard blocks the target route; fallback to stateless without a supervisor; supervisor route redacts a secret result; discovery without target_id rides the supervisor; discovery without a supervisor falls back. Integration (real Chrome): snapshot exposes page_target_id; target_id routes via the supervisor; discovery → evaluate rides one connection. New tests fail before the fix (AttributeError on the missing snapshot field / missing route).

Production verification: self-hosted Browserless v2, Fedora 43, rootless podman — chains that previously landed in a fresh empty browser now resolve against the supervisor session (details in the #32685 / #32950 threads). The supervisor↔daemon split for the dialog/snapshot halves is tracked separately in #74216.

Branch state

On current main — re-homed onto the #102117 module split on 2026-09-04 (both files survived the split; upstream dropped _child_sessions, so resolve_target_session checks the page target then _frames, whose OOPIF frame ids equal their target ids). Happy for this to be cherry-picked / salvaged, or grafted onto #32950 if maintainers prefer the earliest-open PR — either is fine.

Platforms tested

Linux (Fedora 43), Python 3.11; Chromium 148 headless (integration) + in-process mock CDP server (unit). scripts/check-windows-footguns.py clean on touched files.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for carrying forward the private-page guard, public snapshot field, and result-redaction fixes from the earlier review.

Problems

  • The reported Target.getTargetstarget_id workflow is still stateless. The new route is conditional on an already-present target ID at tools/browser_cdp_tool.py:582; Target.getTargets has none and falls through to a fresh _cdp_call at :593-596. That does not make the two calls share a Browserless private browser.
  • page_target_id identifies the supervisor's independently connected browser (tools/browser_supervisor.py:659-690, :770-786), not necessarily the agent-browser daemon's navigated page. The #32685 reproduction documents this split on Browserless, so mixed navigation/CDP usage can silently inspect the wrong page.

Suggested changes

  • Route target discovery through the supervisor connection too, then add a per-WebSocket-isolation test for the full discovery/evaluate chain.
  • Define and test the mixed daemon/supervisor behavior (verified shared target, fallback, or explicit error) before documenting supervisor state as canonical.

Automated hermes-sweeper review.

Comment thread tools/browser_cdp_tool.py Outdated
# routing cannot become the sibling bypass for either (the frame_id
# route follows the same boundary). Falls through to the stateless
# attach when no live supervisor tracks this target.
if target_id:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Target.getTargets has no target_id, so it bypasses this branch and still reaches the new per-call WebSocket at :593-596. On Browserless that means its returned target belongs to a different private browser; route discovery through the supervisor as well and cover the full discovery-to-evaluate sequence.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history labels Jul 30, 2026
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 2 times, most recently from 0409ed7 to c7869e1 Compare July 30, 2026 16:17
@chrisyoung2005

Copy link
Copy Markdown
Author

Both points addressed in c7869e1f4 (rebased onto current main first).

Discovery now rides the supervisor connection. Correct — Target.getTargets had no target_id, skipped the routing branch, and still opened a fresh per-call WebSocket, so on Browserless the discovery result described a different private browser than the one a follow-up target_id call executed in. _browser_cdp_target_via_supervisor now accepts target_id=None and dispatches browser-level commands (no sessionId) on the supervisor's WebSocket — _cdp already treats session_id as optional, so this is the same connection either way — and the call site routes every post-guard call through it when a live supervisor exists. The requested per-WebSocket-isolation test is a real-Chrome integration test covering the full chain: the stateless _cdp_call is patched to fail the test if anything reaches it, then Target.getTargets (asserted to include the supervisor's own attached page — proof both calls observe one browser) followed by Runtime.evaluate on the discovered target_id, both succeeding over the supervisor connection. Unit tests cover the browser-level dispatch shape (no sessionId on the wire) and the no-supervisor stateless fallback.

Mixed daemon/supervisor behavior — defined and documented. The semantics this PR now commits to, of your three options, are verified-shared-connection with stateless fallback:

  • Live supervisor → all browser_cdp traffic (discovery + target-scoped) shares its connection, so results are mutually coherent by construction; responses carry "connection": "supervisor" so callers can see which regime they're in.
  • No supervisor, or a target_id the supervisor has no session for → legacy stateless connection, unchanged (the plain-Chrome path, where every connection sees the shared browser).

On the second half — page_target_id describing the supervisor's independently-connected browser rather than the agent-browser daemon's page: that's exact, and it's the supervisor re-attach root cause we split into #74216 (wrong-page dialog bridge + snapshot enrichment; consolidates the #32685 repro, the plain-Chrome multi-tab confirmation on #71744, and the re-attach direction — a contributor is sequencing that work after #71745). Fixing it belongs at the supervisor attach layer, not per-call in browser_cdp; until it lands, browser.md now states explicitly that the supervisor's browser is not necessarily the daemon's page and that within browser_cdp supervisor state is the canonical view, with a pointer to the tracking issue. Happy to re-scope if you'd rather this PR waits on #74216, but the eval-side companion (#71745) took the same boundary per the triage recommendation.

@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 2 times, most recently from 41db2b6 to dcdcdbc Compare August 3, 2026 10:26
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary

Nine PRs span two distinct causes: #32950 and #71743 address #32685 by reusing a persistent CDP supervisor connection, while #4143, #6623, #27304, #30893, #33246, #34131, and #34497 address execute_code terminal exposure, environment leakage, approval gating, or lost thread context. The execute_code cluster is already consolidated in merged #34497; the remaining open issue is the Browserless per-WebSocket lifecycle handled by the two browser PRs.

Related pull requests

Duplicates

#4143 and #6623 duplicate the terminal-revocation approach. #27304, #30893, #33246, and #34131 are superseded precursors consolidated in merged #34497; #32950 and #71743 share the browser supervisor-reuse mechanism, with #71743 as the expanded salvage.

Suggested consolidation

Keep #71743 open with a salvage path: preserve its same-supervisor discovery-to-evaluate chain, guard, redaction, public snapshot field, fallback tests, and attribution, then obtain contributor re-review of the still-visible keep_open verdict. Despite the keep_open review and recorded best-fix status on #32950, close #32950 as duplicate of #71743 because its diff retains the three cited blockers while #71743 carries its core fix and addresses them; no further action is needed for the execute_code PRs because merged best-fix #34497 is their consolidated reference implementation.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I32685(["issue #32685 (open)"])
    subgraph Dup32950 ["PRs duplicating each other"]
        P32950["PR #32950 (open)"]
        P71743["PR #71743 (open)"]
    end
    P71743 -->|best fix| I32685
    class I32685 open
    class P32950 open
    class P71743 open
    class P32950 best
    class P71743 best
    class P71743 target
    click I32685 "https://github.com/NousResearch/hermes-agent/issues/32685"
    click P32950 "https://github.com/NousResearch/hermes-agent/pull/32950"
    click P71743 "https://github.com/NousResearch/hermes-agent/pull/71743"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 9 pull requests and 5 issues in this complex. Each diff was read against this issue; Assessment working set: 239 kB of PR diffs, 39 kB of issue/PR text, 23 kB of discussion (25 comments), 38 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 7 times, most recently from 0104e8d to a666828 Compare August 10, 2026 10:26
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 4 times, most recently from fb1a245 to 10d66a3 Compare August 14, 2026 10:26
@alt-glitch alt-glitch removed sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades area/sessions Session lifecycle, resume, persistence, history sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 16, 2026
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 2 times, most recently from bcec862 to 7af85c1 Compare August 18, 2026 10:26
@chrisyoung2005

Copy link
Copy Markdown
Author

Thanks — 1 and 4 documented in 6411c34f5; 2 and 3 answered below. No behavior change; unit suite 17 green (test_browser_supervisor.py remains marker-gated as before).

1 (timeout + 2 vs safe_timeout). The two paths' latency contracts are already symmetric at the value level — both receive the same clamped safe_timeout from the tool entrypoint. The +2 is only the outer future guard: the inner supervisor._cdp(...) enforces timeout itself, so the margin exists to let the inner, more specific CDP timeout error surface instead of a generic fut.result timeout, and it only fires if the loop itself is wedged. Now stated in a comment at both call sites.

2 (unlocked _loop/_cdp reads) — declining the lock. As you note, a torn read is caught and falls back to the stateless path, so the worst case is one call taking the slow path. Acquiring _state_lock on the tool hot path would buy determinism nobody observes at the cost of contention with the supervisor's event thread.

3 (plain-Chrome browser-level behavior change). Confirmed no caller depends on stateless isolation: browser_cdp has no internal callers (only its own registry handler), and on plain Chrome targets are shared across connections, so browser-level discovery returns the same data either way. The only observable difference is on per-connection-browser backends — where riding the supervisor's connection is precisely the fix (#32685). The docs note covers it.

4 (partial attach) — comment added at the resolve_target_session early return: _page_target_id known but _page_session_id not yet ⇒ deliberate None, so callers take the stateless fallback rather than racing a half-attached session.

@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 7 times, most recently from 810a51c to b458538 Compare August 25, 2026 10:26
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch from b458538 to 7f6eaa6 Compare August 26, 2026 10:26
@chrisyoung2005

Copy link
Copy Markdown
Author

Status: triage marks this best fix — fixes for the #32685 family; browser-level calls ride the supervisor WebSocket since c7869e1f4, review points documented in 6411c34f5. Rebased onto upstream main daily; re-verified today against the v0.20.5 release tag (v2026.8.19) — cherry-picks clean, test_browser_cdp_tool.py green (test_browser_supervisor.py remains marker-gated for CI). The graft-onto-#32950 offer from the consolidation thread stands — happy to go either route. Ready for review or salvage-merge whenever convenient.

@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 8 times, most recently from 45457df to b68a6b4 Compare September 2, 2026 10:26
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 2 times, most recently from 5c62343 to 1f43f9e Compare September 4, 2026 10:26
NousResearch#32950)

browser_cdp opens a fresh CDP WebSocket per call. On Browserless-style
backends that spawn a private browser per connection, a target id from
one call is invalid by the next — multi-step Target.getTargets ->
target_id workflows always fail (NousResearch#32685).

Route target-scoped calls through the live CDPSupervisor session when
the target is already attached (top-level page, OOPIF frame, or
auto-attached child), falling back to the stateless attach otherwise.

Salvages NousResearch#32950 (credit: @LeonSGP43) rebased onto main with the review
feedback applied:

- the target route runs after the shared private-page guard, so it
  cannot become the sibling bypass fixed for frame_id in 47764f1
  (regression test included)
- public target discovery: SupervisorSnapshot now carries
  page_target_id (surfaced in browser_snapshot output via to_dict);
  the E2E test uses it instead of private supervisor attributes
- the supervisor-backed payload redacts its result via
  _redact_cdp_output like the stateless payload (regression test
  included)
- browser.md no longer claims every non-frame_id call is stateless
…ection too

Review follow-up: Target.getTargets has no target_id, so discovery
bypassed the supervisor branch and opened a fresh per-call WebSocket —
on Browserless-style backends that enumerates a different private
browser than the one target_id-routed calls execute in, leaving the
reported discovery -> target_id workflow stateless.

_browser_cdp_target_via_supervisor now accepts target_id=None and
dispatches browser-level commands (no sessionId) on the supervisor's
WebSocket; the call site routes every post-guard call through it when a
live supervisor exists. Mixed behavior is now defined and documented:
supervisor present -> ALL browser_cdp traffic shares its connection
(responses carry connection: "supervisor"); absent, or target unknown
to the supervisor -> legacy stateless fallback (plain-Chrome path
unchanged). The daemon-vs-supervisor browser split itself is tracked
in issue 74216.

New tests: browser-level discovery routes via the supervisor (unit,
stateless path fails the test if reached), no-supervisor discovery
falls back stateless (unit), and a real-Chrome integration test proving
the full Target.getTargets -> Runtime.evaluate chain rides one
WebSocket. Both supervisor-routing tests fail with the source reverted.
@chrisyoung2005

Copy link
Copy Markdown
Author

Re-homed onto current main after #102117 (the whole-codebase simplification) landed this morning and left this branch conflicting — head 87403628f2, mergeable again. Port notes: both files survived the split; resolve_target_session now checks the page target then _frames (upstream dropped _child_sessions), and the supervisor payload uses the new _redact_cdp_output(always_paths=…, flagged_paths=…) signature. Unit 30 passed; real-Chromium integration 9 passed / 1 skipped. git diff main...HEAD audited to contain only this PR's files; no behavior change vs. the pre-refactor version.

PR body rewritten in the Symptom → change → behavior shape. If cherry-picking/salvaging is the faster path for this one, please go ahead — no objection to authorship going either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/browser Browser automation (CDP, Playwright) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: browser_cdp opens stateless CDP connections, breaking Browserless/BaaS targets between tool calls

5 participants